import { createHash, randomUUID } from 'node:crypto'; import { createReadStream, createWriteStream } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { pipeline } from 'node:stream/promises'; import { Readable } from 'node:stream'; import { promisify } from 'node:util'; import { execFile } from 'node:child_process'; const execFileAsync = promisify(execFile); const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json'); export const DEFAULT_MANIFEST_PATH = path.join(repoRoot, 'build', 'bun-runtime-manifest.json'); export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-runtime'); export const DEFAULT_LICENSES_SOURCE_DIR = path.join(repoRoot, 'resources', 'bun', 'licenses'); export const STAGED_METADATA_FILE = 'metadata.json'; export const REQUIRED_LICENSE_FILES = [ 'Bun-LICENSE.md', 'LGPL-2.0.txt', 'LGPL-2.1.txt', 'SOURCE.md', 'THIRD-PARTY-NOTICES.md', ]; const RELEASE_BASE_URL = 'https://github.com/oven-sh/bun/releases/download'; const SUPPORTED_PLATFORMS = new Set(['darwin', 'linux', 'win32']); const ARCH_BY_BUILDER_VALUE = new Map([ [1, 'x64'], [3, 'arm64'], ['x64', 'x64'], ['arm64', 'arm64'], ]); function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } function readRequiredString(record, key, source) { const value = record[key]; if (typeof value !== 'string' || value.length === 0) { throw new Error(`${source} must contain a non-empty ${key} string.`); } return value; } function readCommit(record, key, source) { const value = readRequiredString(record, key, source); if (!/^[a-f0-9]{40}$/.test(value)) { throw new Error(`${source} must contain a 40-character ${key} commit.`); } return value; } export function normalizeTarget(platform, arch) { if (!SUPPORTED_PLATFORMS.has(platform)) { throw new Error(`Unsupported Bun runtime target platform: ${platform}`); } const normalizedArch = ARCH_BY_BUILDER_VALUE.get(arch); if (!normalizedArch) { throw new Error(`Unsupported Bun runtime target architecture for ${platform}: ${String(arch)}`); } if (platform === 'win32' && normalizedArch !== 'x64') { throw new Error(`Unsupported Bun runtime target: ${platform}-${normalizedArch}`); } return { platform, arch: normalizedArch, key: `${platform}-${normalizedArch}`, executableName: platform === 'win32' ? 'bun.exe' : 'bun', }; } export function parsePackageManagerVersion(packageJson) { if (!isRecord(packageJson)) { throw new Error('package.json must contain a JSON object.'); } const packageManager = readRequiredString(packageJson, 'packageManager', 'package.json'); const match = /^bun@(\d+\.\d+\.\d+)$/.exec(packageManager); if (!match) { throw new Error( `package.json packageManager must pin Bun exactly, received ${packageManager}.`, ); } return match[1]; } function parseArtifact(value, key) { if (!isRecord(value)) { throw new Error(`Bun runtime manifest artifact ${key} must be an object.`); } const file = readRequiredString(value, 'file', `Bun runtime manifest artifact ${key}`); const sha256 = readRequiredString(value, 'sha256', `Bun runtime manifest artifact ${key}`); if (!/^[a-f0-9]{64}$/.test(sha256)) { throw new Error(`Bun runtime manifest artifact ${key} has an invalid SHA-256 digest.`); } if (path.basename(file) !== file || !file.endsWith('.zip')) { throw new Error(`Bun runtime manifest artifact ${key} has an unsafe file name.`); } return { file, sha256 }; } export function parseRuntimeManifest(manifest, version) { if (!isRecord(manifest) || manifest.schemaVersion !== 1) { throw new Error('Bun runtime manifest must use schemaVersion 1.'); } const manifestVersion = readRequiredString(manifest, 'version', 'Bun runtime manifest'); if (manifestVersion !== version) { throw new Error( `Bun runtime manifest version ${manifestVersion} does not match packageManager bun@${version}.`, ); } if (!isRecord(manifest.artifacts)) { throw new Error('Bun runtime manifest must contain an artifacts object.'); } return { version, bunRevision: readCommit(manifest, 'bunRevision', 'Bun runtime manifest'), releaseTagCommit: readCommit(manifest, 'releaseTagCommit', 'Bun runtime manifest'), artifacts: manifest.artifacts, licenseInventoryStatus: readRequiredString( manifest, 'licenseInventoryStatus', 'Bun runtime manifest', ), sourceManifest: readRequiredString(manifest, 'sourceManifest', 'Bun runtime manifest'), correspondingSourceAsset: readRequiredString( manifest, 'correspondingSourceAsset', 'Bun runtime manifest', ), }; } export async function loadRuntimeConfig({ packageJsonPath = DEFAULT_PACKAGE_JSON_PATH, manifestPath = DEFAULT_MANIFEST_PATH, } = {}) { const [packageJsonText, manifestText] = await Promise.all([ fs.readFile(packageJsonPath, 'utf8'), fs.readFile(manifestPath, 'utf8'), ]); const version = parsePackageManagerVersion(JSON.parse(packageJsonText)); return parseRuntimeManifest(JSON.parse(manifestText), version); } export function resolveArtifact(config, platform, arch) { const target = normalizeTarget(platform, arch); const artifactValue = config.artifacts[target.key]; if (artifactValue === undefined) { throw new Error(`Bun runtime manifest has no artifact for ${target.key}.`); } const artifact = parseArtifact(artifactValue, target.key); return { ...target, ...artifact, version: config.version, url: `${RELEASE_BASE_URL}/bun-v${config.version}/${artifact.file}`, }; } export async function sha256File(filePath) { const hash = createHash('sha256'); for await (const chunk of createReadStream(filePath)) { hash.update(chunk); } return hash.digest('hex'); } export async function ensureCachedArchive( artifact, { cacheDir = DEFAULT_CACHE_DIR, fetchImpl = globalThis.fetch } = {}, ) { if (typeof fetchImpl !== 'function') { throw new Error('No fetch implementation is available to download Bun.'); } const versionCacheDir = path.join(cacheDir, artifact.version); const archivePath = path.join(versionCacheDir, artifact.file); await fs.mkdir(versionCacheDir, { recursive: true }); try { if ((await sha256File(archivePath)) === artifact.sha256) return archivePath; await fs.unlink(archivePath); } catch (error) { if (!isRecord(error) || error.code !== 'ENOENT') throw error; } const temporaryPath = `${archivePath}.download-${randomUUID()}`; try { const response = await fetchImpl(artifact.url); if (!response.ok || !response.body) { throw new Error(`Unable to download ${artifact.url}: HTTP ${response.status}`); } await pipeline( Readable.fromWeb(response.body), createWriteStream(temporaryPath, { flags: 'wx' }), ); const actualSha256 = await sha256File(temporaryPath); if (actualSha256 !== artifact.sha256) { throw new Error( `Bun archive checksum mismatch for ${artifact.file}: expected ${artifact.sha256}, received ${actualSha256}.`, ); } await fs.rename(temporaryPath, archivePath); return archivePath; } catch (error) { await fs.rm(temporaryPath, { force: true }); throw error; } } function isSafeZipEntry(entry) { if (entry.startsWith('/') || /^[A-Za-z]:/.test(entry)) return false; return !entry.replaceAll('\\', '/').split('/').includes('..'); } function waitForProcess(child, description) { let stderr = ''; child.stderr.setEncoding('utf8'); child.stderr.on('data', (chunk) => { stderr += chunk; }); return new Promise((resolve, reject) => { child.once('error', reject); child.once('close', (code) => { if (code === 0) resolve(); else reject(new Error(`${description}: ${stderr.trim() || `process exited ${code}`}`)); }); }); } export function buildWindowsExtractionCommand(archivePath, member, outputPath) { const script = [ 'Add-Type -AssemblyName System.IO.Compression.FileSystem', '$archive = [IO.Compression.ZipFile]::OpenRead($env:SUBMINER_BUN_ARCHIVE_PATH)', 'try {', ' $entry = $archive.Entries | Where-Object { $_.FullName -ceq $env:SUBMINER_BUN_ARCHIVE_MEMBER }', ' if ($null -eq $entry) { throw "Archive member not found: $env:SUBMINER_BUN_ARCHIVE_MEMBER" }', ' $inputStream = $entry.Open()', ' $outputStream = [IO.File]::Create($env:SUBMINER_BUN_OUTPUT_PATH)', ' try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose(); $inputStream.Dispose() }', '} finally { $archive.Dispose() }', ].join('; '); return { command: 'powershell.exe', args: [ '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64'), ], environment: { SUBMINER_BUN_ARCHIVE_PATH: archivePath, SUBMINER_BUN_ARCHIVE_MEMBER: member, SUBMINER_BUN_OUTPUT_PATH: outputPath, }, }; } async function extractZipMemberOnWindows(archivePath, member, outputPath) { const command = buildWindowsExtractionCommand(archivePath, member, outputPath); const powershell = spawn(command.command, command.args, { env: { ...process.env, ...command.environment }, stdio: ['ignore', 'ignore', 'pipe'], }); await waitForProcess(powershell, `Unable to extract ${member}`); } export async function extractZipMember(archivePath, member, outputPath) { if (!isSafeZipEntry(member)) { throw new Error(`Refusing to extract unsafe Bun archive member ${member}.`); } await fs.mkdir(path.dirname(outputPath), { recursive: true }); const temporaryPath = `${outputPath}.extract-${randomUUID()}`; if (process.platform === 'win32') { try { await extractZipMemberOnWindows(archivePath, member, temporaryPath); await fs.chmod(temporaryPath, 0o755); await fs.rename(temporaryPath, outputPath); } catch (error) { await fs.rm(temporaryPath, { force: true }); throw error; } return; } const { stdout } = await execFileAsync('unzip', ['-Z1', archivePath], { encoding: 'utf8', maxBuffer: 1024 * 1024, }); const entries = stdout.split(/\r?\n/).filter(Boolean); if (entries.some((entry) => !isSafeZipEntry(entry))) { throw new Error(`Bun archive ${archivePath} contains an unsafe path.`); } if (!entries.includes(member)) { throw new Error(`Bun archive ${archivePath} does not contain ${member}.`); } const output = createWriteStream(temporaryPath, { flags: 'wx', mode: 0o755 }); const unzip = spawn('unzip', ['-p', archivePath, member], { stdio: ['ignore', 'pipe', 'pipe'] }); try { await Promise.all([ pipeline(unzip.stdout, output), waitForProcess(unzip, `Unable to extract ${member}`), ]); await fs.chmod(temporaryPath, 0o755); await fs.rename(temporaryPath, outputPath); } catch (error) { await fs.rm(temporaryPath, { force: true }); throw error; } } export function resolveResourcesDirectory(appOutDir, platform, productFilename = 'SubMiner') { if (platform !== 'darwin') return path.join(appOutDir, 'resources'); const appBundlePath = appOutDir.endsWith('.app') ? appOutDir : path.join(appOutDir, `${productFilename}.app`); return path.join(appBundlePath, 'Contents', 'Resources'); } export async function stageBunLicenses( runtimeDirectory, licensesSourceDir = DEFAULT_LICENSES_SOURCE_DIR, ) { const licensesDirectory = path.join(runtimeDirectory, 'licenses'); await Promise.all( REQUIRED_LICENSE_FILES.map((fileName) => fs.access(path.join(licensesSourceDir, fileName))), ); await fs.cp(licensesSourceDir, licensesDirectory, { recursive: true, force: true }); return licensesDirectory; } export async function stageBunRuntime( { appOutDir, platform, arch, productFilename = 'SubMiner' }, { configLoader = loadRuntimeConfig, archiveLoader = ensureCachedArchive, extractor = extractZipMember, licenseStager = stageBunLicenses, } = {}, ) { const config = await configLoader(); const artifact = resolveArtifact(config, platform, arch); const archivePath = await archiveLoader(artifact); const archiveDirectory = path.basename(artifact.file, '.zip'); const archiveExecutableName = platform === 'win32' ? 'bun.exe' : 'bun'; const member = `${archiveDirectory}/${archiveExecutableName}`; const runtimeDirectory = path.join( resolveResourcesDirectory(appOutDir, platform, productFilename), 'bun', ); const executablePath = path.join(runtimeDirectory, artifact.executableName); await extractor(archivePath, member, executablePath); await licenseStager(runtimeDirectory); const metadata = { name: 'Bun', version: config.version, bunRevision: config.bunRevision, releaseTagCommit: config.releaseTagCommit, target: artifact.key, artifact: artifact.file, artifactSha256: artifact.sha256, sourceUrl: artifact.url, licenseInventoryStatus: config.licenseInventoryStatus, correspondingSourceAsset: config.correspondingSourceAsset, sourceInstructions: 'licenses/SOURCE.md', thirdPartyNotices: 'licenses/THIRD-PARTY-NOTICES.md', }; await fs.writeFile( path.join(runtimeDirectory, STAGED_METADATA_FILE), `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o644 }, ); return { executablePath, metadataPath: path.join(runtimeDirectory, STAGED_METADATA_FILE), artifact, }; }