mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 05:16:27 -07:00
feat(launcher): bundle a private Bun runtime
This commit is contained in:
@@ -979,7 +979,7 @@ function renderReleaseNotes(
|
||||
'- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`',
|
||||
'- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher',
|
||||
'',
|
||||
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
|
||||
"The launcher installed by desktop setup uses the app's bundled Bun runtime. Only the separately downloaded `subminer` script requires Bun installed on `PATH`.",
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -86,15 +86,32 @@ async function verifyMacOSWindowHelper(
|
||||
return true;
|
||||
}
|
||||
|
||||
async function afterPack(context) {
|
||||
async function stageBundledBunRuntime(context, deps = {}) {
|
||||
const stageBunRuntime =
|
||||
deps.stageBunRuntime ?? (await import('./stage-bun-runtime.mjs')).stageBunRuntime;
|
||||
const productFilename = context.packager?.appInfo?.productFilename;
|
||||
await stageBunRuntime({
|
||||
appOutDir: context.appOutDir,
|
||||
platform: context.electronPlatformName,
|
||||
arch: context.arch,
|
||||
productFilename:
|
||||
typeof productFilename === 'string' && productFilename.trim()
|
||||
? productFilename.trim()
|
||||
: 'SubMiner',
|
||||
});
|
||||
}
|
||||
|
||||
async function afterPack(context, deps = {}) {
|
||||
await stageLinuxAppImageSharedLibrary(context);
|
||||
await verifyMacOSWindowHelper(context);
|
||||
await stageBundledBunRuntime(context, deps);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LINUX_FFMPEG_LIBRARY,
|
||||
MACOS_WINDOW_HELPER,
|
||||
resolveMacOSAppBundlePath,
|
||||
stageBundledBunRuntime,
|
||||
stageLinuxAppImageSharedLibrary,
|
||||
verifyMacOSWindowHelper,
|
||||
default: afterPack,
|
||||
|
||||
@@ -13,7 +13,22 @@ const {
|
||||
} = require('./electron-builder-after-pack.cjs') as {
|
||||
LINUX_FFMPEG_LIBRARY: string;
|
||||
MACOS_WINDOW_HELPER: string;
|
||||
default: (context: { appOutDir: string; electronPlatformName: string }) => Promise<void>;
|
||||
default: (
|
||||
context: {
|
||||
appOutDir: string;
|
||||
arch?: number;
|
||||
electronPlatformName: string;
|
||||
packager?: { appInfo?: { productFilename?: string } };
|
||||
},
|
||||
deps?: {
|
||||
stageBunRuntime?: (options: {
|
||||
appOutDir: string;
|
||||
platform: string;
|
||||
arch: number | undefined;
|
||||
productFilename: string;
|
||||
}) => Promise<void>;
|
||||
},
|
||||
) => Promise<void>;
|
||||
stageLinuxAppImageSharedLibrary: (context: {
|
||||
appOutDir: string;
|
||||
electronPlatformName: string;
|
||||
@@ -156,3 +171,47 @@ test('afterPack propagates Linux staging failures', async () => {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('afterPack preserves Linux staging and forwards the electron-builder target to Bun staging', async () => {
|
||||
const workspace = createWorkspace('subminer-after-pack-target');
|
||||
const appOutDir = path.join(workspace, 'SubMiner-linux-arm64');
|
||||
const sourceLibraryPath = path.join(appOutDir, LINUX_FFMPEG_LIBRARY);
|
||||
const targetLibraryPath = path.join(appOutDir, 'usr', 'lib', LINUX_FFMPEG_LIBRARY);
|
||||
let stagedOptions:
|
||||
| {
|
||||
appOutDir: string;
|
||||
platform: string;
|
||||
arch: number | undefined;
|
||||
productFilename: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
fs.mkdirSync(appOutDir, { recursive: true });
|
||||
fs.writeFileSync(sourceLibraryPath, 'bundled ffmpeg', 'utf8');
|
||||
|
||||
try {
|
||||
await afterPack(
|
||||
{
|
||||
appOutDir,
|
||||
arch: 3,
|
||||
electronPlatformName: 'linux',
|
||||
packager: { appInfo: { productFilename: 'SubMiner Preview' } },
|
||||
},
|
||||
{
|
||||
stageBunRuntime: async (options) => {
|
||||
stagedOptions = options;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(stagedOptions, {
|
||||
appOutDir,
|
||||
platform: 'linux',
|
||||
arch: 3,
|
||||
productFilename: 'SubMiner Preview',
|
||||
});
|
||||
assert.equal(fs.readFileSync(targetLibraryPath, 'utf8'), 'bundled ffmpeg');
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
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';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
||||
export const DEFAULT_MANIFEST_PATH = path.join(repoRoot, 'build', 'bun-source-manifest.json');
|
||||
export const DEFAULT_RUNTIME_MANIFEST_PATH = path.join(
|
||||
repoRoot,
|
||||
'build',
|
||||
'bun-runtime-manifest.json',
|
||||
);
|
||||
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
||||
export const DEFAULT_OUTPUT_DIR = path.join(repoRoot, 'release');
|
||||
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-corresponding-source');
|
||||
|
||||
function isRecord(value) {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requiredString(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 assertSafeRelativePath(value, source) {
|
||||
if (path.isAbsolute(value) || value.split(/[\\/]/).includes('..')) {
|
||||
throw new Error(`${source} contains an unsafe path: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSourceManifest(value) {
|
||||
if (!isRecord(value) || value.schemaVersion !== 1) {
|
||||
throw new Error('Bun source manifest must use schemaVersion 1.');
|
||||
}
|
||||
const version = requiredString(value, 'version', 'Bun source manifest');
|
||||
const bunRevision = requiredString(value, 'bunRevision', 'Bun source manifest');
|
||||
const archiveName = requiredString(value, 'archiveName', 'Bun source manifest');
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version) || !/^[a-f0-9]{40}$/.test(bunRevision)) {
|
||||
throw new Error('Bun source manifest has an invalid version or bunRevision.');
|
||||
}
|
||||
if (path.basename(archiveName) !== archiveName || !archiveName.endsWith('.tar.gz')) {
|
||||
throw new Error('Bun source manifest archiveName must be a safe .tar.gz filename.');
|
||||
}
|
||||
if (!Array.isArray(value.sources) || value.sources.length === 0) {
|
||||
throw new Error('Bun source manifest must contain sources.');
|
||||
}
|
||||
|
||||
const names = new Set();
|
||||
const destinations = new Set();
|
||||
const sources = value.sources.map((entry, index) => {
|
||||
if (!isRecord(entry)) throw new Error(`Bun source ${index} must be an object.`);
|
||||
const name = requiredString(entry, 'name', `Bun source ${index}`);
|
||||
const repository = requiredString(entry, 'repository', `Bun source ${name}`);
|
||||
const revision = requiredString(entry, 'revision', `Bun source ${name}`);
|
||||
const destination = requiredString(entry, 'destination', `Bun source ${name}`);
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(repository) || !/^[a-f0-9]{40}$/.test(revision)) {
|
||||
throw new Error(`Bun source ${name} has an invalid repository or revision.`);
|
||||
}
|
||||
assertSafeRelativePath(destination, `Bun source ${name}`);
|
||||
if (names.has(name) || destinations.has(destination)) {
|
||||
throw new Error(`Bun source manifest repeats ${name} or ${destination}.`);
|
||||
}
|
||||
names.add(name);
|
||||
destinations.add(destination);
|
||||
|
||||
const transport = entry.transport ?? 'archive';
|
||||
if (transport !== 'archive' && transport !== 'git-sparse') {
|
||||
throw new Error(`Bun source ${name} has unsupported transport ${transport}.`);
|
||||
}
|
||||
const sha256 =
|
||||
transport === 'archive' ? requiredString(entry, 'sha256', `Bun source ${name}`) : null;
|
||||
if (sha256 !== null && !/^[a-f0-9]{64}$/.test(sha256)) {
|
||||
throw new Error(`Bun source ${name} has an invalid SHA-256 digest.`);
|
||||
}
|
||||
if (!Array.isArray(entry.licensePaths) || entry.licensePaths.length === 0) {
|
||||
throw new Error(`Bun source ${name} must declare licensePaths.`);
|
||||
}
|
||||
const licensePaths = entry.licensePaths.map((licensePath) => {
|
||||
if (typeof licensePath !== 'string' || licensePath.length === 0) {
|
||||
throw new Error(`Bun source ${name} has an invalid license path.`);
|
||||
}
|
||||
assertSafeRelativePath(licensePath, `Bun source ${name}`);
|
||||
return licensePath;
|
||||
});
|
||||
const exclude = Array.isArray(entry.exclude) ? entry.exclude : [];
|
||||
for (const excludedPath of exclude) assertSafeRelativePath(excludedPath, `Bun source ${name}`);
|
||||
return {
|
||||
...entry,
|
||||
name,
|
||||
repository,
|
||||
revision,
|
||||
destination,
|
||||
transport,
|
||||
sha256,
|
||||
licensePaths,
|
||||
exclude,
|
||||
};
|
||||
});
|
||||
|
||||
const bun = sources.find((source) => source.name === 'bun');
|
||||
if (!bun || bun.revision !== bunRevision || bun.destination !== 'bun') {
|
||||
throw new Error('Bun source manifest must map bunRevision to the bun source at bun/.');
|
||||
}
|
||||
return { ...value, version, bunRevision, archiveName, sources };
|
||||
}
|
||||
|
||||
export function parseRegisteredRepositories(cmakeText) {
|
||||
const registrations = new Map();
|
||||
const uncommented = cmakeText.replace(/#[^\n]*/g, '');
|
||||
for (const match of uncommented.matchAll(/register_repository\(([\s\S]*?)\)/g)) {
|
||||
const body = match[1];
|
||||
const name = /\bNAME\s+([^\s#)]+)/.exec(body)?.[1];
|
||||
const repository = /\bREPOSITORY\s+([^\s#)]+)/.exec(body)?.[1];
|
||||
const reference = /\b(COMMIT|TAG)\s+(?:#[^\n]*\n\s*)?([^\s#)]+)/.exec(body);
|
||||
if (name && repository && reference) {
|
||||
registrations.set(name, {
|
||||
repository,
|
||||
kind: reference[1].toLowerCase(),
|
||||
reference: reference[2],
|
||||
});
|
||||
}
|
||||
}
|
||||
return registrations;
|
||||
}
|
||||
|
||||
export function validateRuntimeAlignment(manifest, packageJson, runtimeManifest) {
|
||||
if (!isRecord(packageJson) || packageJson.packageManager !== `bun@${manifest.version}`) {
|
||||
throw new Error(
|
||||
`package.json must pin bun@${manifest.version} to match the Bun source manifest.`,
|
||||
);
|
||||
}
|
||||
if (!isRecord(runtimeManifest)) throw new Error('Bun runtime manifest must be an object.');
|
||||
for (const key of ['version', 'bunRevision']) {
|
||||
if (runtimeManifest[key] !== manifest[key]) {
|
||||
throw new Error(`Bun runtime manifest ${key} does not match the Bun source manifest.`);
|
||||
}
|
||||
}
|
||||
if (runtimeManifest.correspondingSourceAsset !== manifest.archiveName) {
|
||||
throw new Error(
|
||||
'Bun runtime manifest correspondingSourceAsset does not match the Bun source manifest.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBunPins(manifest, cmakeTexts, setupWebKitText) {
|
||||
const actual = new Map();
|
||||
for (const cmakeText of cmakeTexts) {
|
||||
for (const [name, registration] of parseRegisteredRepositories(cmakeText)) {
|
||||
if (actual.has(name)) throw new Error(`Bun registers ${name} more than once.`);
|
||||
actual.set(name, registration);
|
||||
}
|
||||
}
|
||||
const expected = new Map(
|
||||
manifest.sources
|
||||
.filter((source) => source.destination.startsWith('bun/vendor/') && source.name !== 'WebKit')
|
||||
.map((source) => [source.name, source]),
|
||||
);
|
||||
for (const [name, registration] of actual) {
|
||||
const source = expected.get(name);
|
||||
if (!source) throw new Error(`Source manifest omits Bun dependency ${name}.`);
|
||||
if (source.repository !== registration.repository) {
|
||||
throw new Error(`Source manifest repository mismatch for ${name}.`);
|
||||
}
|
||||
const expectedReference = source.upstreamReference ?? source.revision;
|
||||
if (expectedReference !== registration.reference) {
|
||||
throw new Error(`Source manifest revision mismatch for ${name}.`);
|
||||
}
|
||||
expected.delete(name);
|
||||
}
|
||||
if (expected.size > 0) {
|
||||
throw new Error(
|
||||
`Source manifest has unregistered Bun dependencies: ${[...expected.keys()].join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const webKitPin = /set\(WEBKIT_VERSION\s+([a-f0-9]{40})\)/.exec(setupWebKitText)?.[1];
|
||||
const webKit = manifest.sources.find((source) => source.name === 'WebKit');
|
||||
if (!webKitPin || !webKit || webKit.revision !== webKitPin) {
|
||||
throw new Error('Source manifest WebKit revision does not match SetupWebKit.cmake.');
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256File(filePath) {
|
||||
const hash = createHash('sha256');
|
||||
for await (const chunk of createReadStream(filePath)) hash.update(chunk);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
async function run(command, args, options = {}) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: 'inherit', ...options });
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${command} exited with status ${code}.`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadArchive(source, cacheDir, fetchImpl) {
|
||||
const archivePath = path.join(cacheDir, `${source.name}-${source.revision}.tar.gz`);
|
||||
try {
|
||||
if ((await sha256File(archivePath)) === source.sha256) return archivePath;
|
||||
await fs.rm(archivePath, { force: true });
|
||||
} catch (error) {
|
||||
if (!isRecord(error) || error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
const url = `https://codeload.github.com/${source.repository}/tar.gz/${source.revision}`;
|
||||
const temporaryPath = `${archivePath}.download-${randomUUID()}`;
|
||||
const response = await fetchImpl(url);
|
||||
if (!response.ok || !response.body)
|
||||
throw new Error(`Unable to download ${url}: HTTP ${response.status}`);
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body),
|
||||
createWriteStream(temporaryPath, { flags: 'wx' }),
|
||||
);
|
||||
const actualSha256 = await sha256File(temporaryPath);
|
||||
if (actualSha256 !== source.sha256) {
|
||||
await fs.rm(temporaryPath, { force: true });
|
||||
throw new Error(
|
||||
`Source checksum mismatch for ${source.name}: expected ${source.sha256}, received ${actualSha256}.`,
|
||||
);
|
||||
}
|
||||
await fs.rename(temporaryPath, archivePath);
|
||||
return archivePath;
|
||||
}
|
||||
|
||||
async function materializeArchive(source, root, cacheDir, fetchImpl) {
|
||||
const archivePath = await downloadArchive(source, cacheDir, fetchImpl);
|
||||
const destination = path.join(root, source.destination);
|
||||
await fs.mkdir(destination, { recursive: true });
|
||||
await run('tar', ['-xzf', archivePath, '-C', destination, '--strip-components=1']);
|
||||
}
|
||||
|
||||
async function materializeGitSparse(source, root, cacheDir) {
|
||||
const checkout = path.join(cacheDir, `${source.name}-${source.revision}-git`);
|
||||
await fs.rm(checkout, { recursive: true, force: true });
|
||||
await run('git', [
|
||||
'clone',
|
||||
'--filter=blob:none',
|
||||
'--no-checkout',
|
||||
'--depth=1',
|
||||
`https://github.com/${source.repository}.git`,
|
||||
checkout,
|
||||
]);
|
||||
await run('git', ['-C', checkout, 'fetch', '--depth=1', 'origin', source.revision]);
|
||||
await run('git', ['-C', checkout, 'sparse-checkout', 'init', '--no-cone']);
|
||||
const sparseRules = ['/*', ...source.exclude.map((entry) => `!/${entry}/`), ''];
|
||||
await fs.writeFile(
|
||||
path.join(checkout, '.git', 'info', 'sparse-checkout'),
|
||||
sparseRules.join('\n'),
|
||||
);
|
||||
await run('git', ['-C', checkout, 'checkout', '--detach', source.revision]);
|
||||
const actualRevision = (await fs.readFile(path.join(checkout, '.git', 'HEAD'), 'utf8')).trim();
|
||||
if (actualRevision !== source.revision)
|
||||
throw new Error(`Git checkout mismatch for ${source.name}.`);
|
||||
await fs.rm(path.join(checkout, '.git'), { recursive: true, force: true });
|
||||
await fs.mkdir(path.dirname(path.join(root, source.destination)), { recursive: true });
|
||||
await fs.rename(checkout, path.join(root, source.destination));
|
||||
}
|
||||
|
||||
async function applyBunDependencyPatches(root, manifest) {
|
||||
const bunRoot = path.join(root, 'bun');
|
||||
for (const source of manifest.sources) {
|
||||
if (!source.destination.startsWith('bun/vendor/') || source.name === 'WebKit') continue;
|
||||
const destination = path.join(root, source.destination);
|
||||
const patchDirectory = path.join(bunRoot, 'patches', source.name);
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.readdir(patchDirectory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (!isRecord(error) || error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const patchPath = path.join(patchDirectory, entry.name);
|
||||
if (entry.isFile() && entry.name.endsWith('.patch')) {
|
||||
await run(
|
||||
'git',
|
||||
['apply', '--ignore-whitespace', '--ignore-space-change', '--no-index', patchPath],
|
||||
{ cwd: destination },
|
||||
);
|
||||
} else if (entry.isFile()) {
|
||||
await fs.copyFile(patchPath, path.join(destination, entry.name));
|
||||
}
|
||||
}
|
||||
const cmakeReference = source.upstreamReference
|
||||
? `refs/tags/${source.upstreamReference}`
|
||||
: source.revision;
|
||||
await fs.writeFile(path.join(destination, '.ref'), `${cmakeReference}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAndCollectLicenses(root, manifest) {
|
||||
const licensesRoot = path.join(root, 'THIRD-PARTY-LICENSES');
|
||||
await fs.mkdir(licensesRoot, { recursive: true });
|
||||
for (const source of manifest.sources) {
|
||||
const target = path.join(licensesRoot, source.name);
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
for (const licensePath of source.licensePaths) {
|
||||
const sourcePath = path.join(root, source.destination, licensePath);
|
||||
const stat = await fs.stat(sourcePath).catch(() => null);
|
||||
if (!stat?.isFile() || stat.size === 0) {
|
||||
throw new Error(`Missing required license material for ${source.name}: ${licensePath}`);
|
||||
}
|
||||
const safeName = licensePath.replaceAll('/', '__');
|
||||
await fs.copyFile(sourcePath, path.join(target, safeName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildReadme(manifest) {
|
||||
const webKit = manifest.sources.find((source) => source.name === 'WebKit');
|
||||
const tinycc = manifest.sources.find((source) => source.name === 'tinycc');
|
||||
return `# Bun ${manifest.version} corresponding source and rebuild materials
|
||||
|
||||
This archive matches the official Bun ${manifest.version} binaries whose \`bun --revision\` output names commit \`${manifest.bunRevision}\`. The GitHub release tag points to \`${manifest.releaseTagCommit}\`, one later commit, so this package intentionally uses the binary revision.
|
||||
|
||||
The archive includes Bun's complete source tree, Bun's build scripts and dependency patches, every external repository registered by Bun's CMake build at its exact pin, and Oven's WebKit fork at \`${webKit.revision}\`. Large WebKit test-only trees are excluded. The JavaScriptCore, WTF, WebCore, build-tool, configuration, and resource trees used to build the library are included. TinyCC is \`${tinycc.revision}\`.
|
||||
|
||||
## Rebuild with modified JavaScriptCore
|
||||
|
||||
Install the prerequisites recorded in \`bun/.buildkite/Dockerfile\`, \`bun/scripts/bootstrap.sh\`, and the WebKit platform build scripts. Bun ${manifest.version} used LLVM 19.1.7, CMake 3.30.5 in its Linux build image, and Bun 1.1.38 as the bootstrap runtime. Its Rust input was nightly and was not pinned to a dated toolchain in the release source.
|
||||
|
||||
From this archive root on Linux or macOS:
|
||||
|
||||
\`\`\`sh
|
||||
cd bun
|
||||
bun install --frozen-lockfile
|
||||
bun run jsc:build
|
||||
bun run build:release:local -- -DVERSION=${manifest.version} -DREVISION=${manifest.bunRevision}
|
||||
\`\`\`
|
||||
|
||||
The first command uses the bootstrap Bun. \`jsc:build\` builds the included \`vendor/WebKit\` checkout into \`vendor/WebKit/WebKitBuild/Release\`. \`build:release:local\` links Bun against that local JavaScriptCore build. The explicit version and revision replace metadata that Bun normally reads from its Git checkout. The included \`vendor/*/.ref\` files prevent Bun's CMake rules from replacing the packaged dependency sources, and this package has already applied the files under \`bun/patches/<dependency>/\` in the same order as \`bun/cmake/scripts/GitClone.cmake\`.
|
||||
|
||||
The archive vendors the source repositories that Bun's CMake build links into the executable. It preserves Bun's \`bun.lock\` files and lol-html's \`Cargo.lock\`, but it does not vendor npm packages, crates.io packages used to build lol-html, compilers, SDKs, or other build tools. The rebuild therefore needs network access for those pinned package-manager inputs. License notices embedded in those downloaded packages are outside the collected \`THIRD-PARTY-LICENSES\` directory's scope.
|
||||
|
||||
Windows uses the prerequisites in \`bun/docs/project/building-windows.mdx\` and WebKit's \`windows-release.ps1\`. The local-JavaScriptCore path above has not been verified on Windows.
|
||||
|
||||
These instructions describe the source and build entry points. Toolchain and generated-output differences mean a rebuild is not expected to be byte-for-byte identical to Oven's release binary. No claim about legal compliance or reproducible builds is made here.
|
||||
`;
|
||||
}
|
||||
|
||||
async function createDeterministicArchive(stagingParent, rootName, outputPath) {
|
||||
const temporaryTar = `${outputPath}.tar-${randomUUID()}`;
|
||||
const temporaryGzip = `${outputPath}.gzip-${randomUUID()}`;
|
||||
try {
|
||||
await run('tar', [
|
||||
'--sort=name',
|
||||
'--mtime=@0',
|
||||
'--owner=0',
|
||||
'--group=0',
|
||||
'--numeric-owner',
|
||||
'-cf',
|
||||
temporaryTar,
|
||||
'-C',
|
||||
stagingParent,
|
||||
rootName,
|
||||
]);
|
||||
const gzip = spawn('gzip', ['-n', '-9', '-c', temporaryTar], {
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
});
|
||||
const completion = new Promise((resolve, reject) => {
|
||||
gzip.once('error', reject);
|
||||
gzip.once('close', resolve);
|
||||
});
|
||||
const [, code] = await Promise.all([
|
||||
pipeline(gzip.stdout, createWriteStream(temporaryGzip, { flags: 'wx' })),
|
||||
completion,
|
||||
]);
|
||||
if (code !== 0) throw new Error(`gzip exited with status ${code}.`);
|
||||
await fs.rm(outputPath, { force: true });
|
||||
await fs.rename(temporaryGzip, outputPath);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
fs.rm(temporaryTar, { force: true }),
|
||||
fs.rm(temporaryGzip, { force: true }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function packageBunSource({
|
||||
manifestPath = DEFAULT_MANIFEST_PATH,
|
||||
runtimeManifestPath = DEFAULT_RUNTIME_MANIFEST_PATH,
|
||||
packageJsonPath = DEFAULT_PACKAGE_JSON_PATH,
|
||||
outputDir = DEFAULT_OUTPUT_DIR,
|
||||
cacheDir = DEFAULT_CACHE_DIR,
|
||||
fetchImpl = globalThis.fetch,
|
||||
} = {}) {
|
||||
const [manifestText, runtimeManifestText, packageJsonText] = await Promise.all([
|
||||
fs.readFile(manifestPath, 'utf8'),
|
||||
fs.readFile(runtimeManifestPath, 'utf8'),
|
||||
fs.readFile(packageJsonPath, 'utf8'),
|
||||
]);
|
||||
const manifest = parseSourceManifest(JSON.parse(manifestText));
|
||||
validateRuntimeAlignment(manifest, JSON.parse(packageJsonText), JSON.parse(runtimeManifestText));
|
||||
if (typeof fetchImpl !== 'function') throw new Error('No fetch implementation is available.');
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
const stagingParent = await fs.mkdtemp(path.join(cacheDir, 'assemble-'));
|
||||
const rootName = path.basename(manifest.archiveName, '.tar.gz');
|
||||
const root = path.join(stagingParent, rootName);
|
||||
await fs.mkdir(root);
|
||||
|
||||
try {
|
||||
const bun = manifest.sources.find((source) => source.name === 'bun');
|
||||
await materializeArchive(bun, root, cacheDir, fetchImpl);
|
||||
const cmakeFiles = (await fs.readdir(path.join(root, 'bun', 'cmake', 'targets')))
|
||||
.filter((name) => name.endsWith('.cmake'))
|
||||
.map((name) => fs.readFile(path.join(root, 'bun', 'cmake', 'targets', name), 'utf8'));
|
||||
validateBunPins(
|
||||
manifest,
|
||||
await Promise.all(cmakeFiles),
|
||||
await fs.readFile(path.join(root, 'bun', 'cmake', 'tools', 'SetupWebKit.cmake'), 'utf8'),
|
||||
);
|
||||
|
||||
for (const source of manifest.sources.filter((entry) => entry.name !== 'bun')) {
|
||||
if (source.transport === 'git-sparse') await materializeGitSparse(source, root, cacheDir);
|
||||
else await materializeArchive(source, root, cacheDir, fetchImpl);
|
||||
}
|
||||
await applyBunDependencyPatches(root, manifest);
|
||||
await validateAndCollectLicenses(root, manifest);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'SOURCE-INVENTORY.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
await fs.writeFile(path.join(root, 'README-REBUILD.md'), rebuildReadme(manifest));
|
||||
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const outputPath = path.join(outputDir, manifest.archiveName);
|
||||
await createDeterministicArchive(stagingParent, rootName, outputPath);
|
||||
const digest = await sha256File(outputPath);
|
||||
await fs.writeFile(`${outputPath}.sha256`, `${digest} ${manifest.archiveName}\n`);
|
||||
return { outputPath, sha256: digest };
|
||||
} finally {
|
||||
await fs.rm(stagingParent, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const result = await packageBunSource();
|
||||
console.log(`${result.sha256} ${path.basename(result.outputPath)}`);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
parseRegisteredRepositories,
|
||||
parseSourceManifest,
|
||||
validateBunPins,
|
||||
validateRuntimeAlignment,
|
||||
} from './package-bun-source.mjs';
|
||||
|
||||
const projectRoot = path.resolve(import.meta.dir, '..');
|
||||
|
||||
describe('Bun corresponding-source manifest', () => {
|
||||
test('pins the source to the revision reported by the distributed binary', async () => {
|
||||
const manifest = parseSourceManifest(
|
||||
JSON.parse(
|
||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(manifest.version).toBe('1.3.5');
|
||||
expect(manifest.bunRevision).toBe('1e86cebd74a5723e818b5c0555276b646bcf0e4c');
|
||||
expect(manifest.releaseTagCommit).toBe('fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab');
|
||||
expect(manifest.sources.find((source) => source.name === 'WebKit')?.revision).toBe(
|
||||
'6d0f3aac0b817cc01a846b3754b21271adedac12',
|
||||
);
|
||||
expect(manifest.sources.find((source) => source.name === 'tinycc')?.revision).toBe(
|
||||
'29985a3b59898861442fa3b43f663fc1af2591d7',
|
||||
);
|
||||
});
|
||||
|
||||
test('parses commit and tag registrations from Bun CMake', () => {
|
||||
const registrations = parseRegisteredRepositories(`
|
||||
register_repository(
|
||||
NAME tinycc
|
||||
REPOSITORY oven-sh/tinycc
|
||||
COMMIT
|
||||
# A comment between the field and its value is valid CMake.
|
||||
29985a3b59898861442fa3b43f663fc1af2591d7
|
||||
)
|
||||
register_repository(
|
||||
NAME brotli
|
||||
REPOSITORY google/brotli
|
||||
TAG v1.1.0
|
||||
)
|
||||
`);
|
||||
|
||||
expect(registrations.get('tinycc')).toEqual({
|
||||
repository: 'oven-sh/tinycc',
|
||||
kind: 'commit',
|
||||
reference: '29985a3b59898861442fa3b43f663fc1af2591d7',
|
||||
});
|
||||
expect(registrations.get('brotli')).toEqual({
|
||||
repository: 'google/brotli',
|
||||
kind: 'tag',
|
||||
reference: 'v1.1.0',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects stale runtime or package-manager pins', async () => {
|
||||
const manifest = parseSourceManifest(
|
||||
JSON.parse(
|
||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
||||
),
|
||||
);
|
||||
const runtimeManifest = {
|
||||
version: manifest.version,
|
||||
bunRevision: manifest.bunRevision,
|
||||
correspondingSourceAsset: manifest.archiveName,
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateRuntimeAlignment(manifest, { packageManager: 'bun@1.3.5' }, runtimeManifest),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateRuntimeAlignment(manifest, { packageManager: 'bun@1.3.6' }, runtimeManifest),
|
||||
).toThrow('package.json must pin bun@1.3.5');
|
||||
expect(() =>
|
||||
validateRuntimeAlignment(
|
||||
manifest,
|
||||
{ packageManager: 'bun@1.3.5' },
|
||||
{
|
||||
...runtimeManifest,
|
||||
correspondingSourceAsset: 'stale.tar.gz',
|
||||
},
|
||||
),
|
||||
).toThrow('correspondingSourceAsset does not match');
|
||||
});
|
||||
|
||||
test('rejects drift in CMake dependency and WebKit pins', async () => {
|
||||
const manifest = parseSourceManifest(
|
||||
JSON.parse(
|
||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
||||
),
|
||||
);
|
||||
const registrations = manifest.sources
|
||||
.filter((source) => source.destination.startsWith('bun/vendor/') && source.name !== 'WebKit')
|
||||
.map(
|
||||
(source) => `register_repository(
|
||||
NAME ${source.name}
|
||||
REPOSITORY ${source.repository}
|
||||
${source.upstreamReference ? 'TAG' : 'COMMIT'} ${source.upstreamReference ?? source.revision}
|
||||
)`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
expect(() =>
|
||||
validateBunPins(
|
||||
manifest,
|
||||
[registrations],
|
||||
'set(WEBKIT_VERSION 6d0f3aac0b817cc01a846b3754b21271adedac12)',
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateBunPins(
|
||||
manifest,
|
||||
[registrations.replace('29985a3b59898861442fa3b43f663fc1af2591d7', '0'.repeat(40))],
|
||||
'set(WEBKIT_VERSION 6d0f3aac0b817cc01a846b3754b21271adedac12)',
|
||||
),
|
||||
).toThrow('revision mismatch for tinycc');
|
||||
expect(() =>
|
||||
validateBunPins(manifest, [registrations], `set(WEBKIT_VERSION ${'0'.repeat(40)})`),
|
||||
).toThrow('WebKit revision does not match');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildWindowsExtractionCommand,
|
||||
ensureCachedArchive,
|
||||
extractZipMember,
|
||||
loadRuntimeConfig,
|
||||
normalizeTarget,
|
||||
parsePackageManagerVersion,
|
||||
parseRuntimeManifest,
|
||||
resolveArtifact,
|
||||
stageBunLicenses,
|
||||
stageBunRuntime,
|
||||
} from './stage-bun-runtime.mjs';
|
||||
|
||||
function sha256(content: Uint8Array): string {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function runtimeConfig() {
|
||||
return {
|
||||
version: '1.3.5',
|
||||
bunRevision: '1e86cebd74a5723e818b5c0555276b646bcf0e4c',
|
||||
releaseTagCommit: 'fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab',
|
||||
artifacts: {
|
||||
'darwin-arm64': { file: 'bun-darwin-aarch64.zip', sha256: '1'.repeat(64) },
|
||||
'darwin-x64': { file: 'bun-darwin-x64-baseline.zip', sha256: '2'.repeat(64) },
|
||||
'linux-arm64': { file: 'bun-linux-aarch64.zip', sha256: '3'.repeat(64) },
|
||||
'linux-x64': { file: 'bun-linux-x64-baseline.zip', sha256: '4'.repeat(64) },
|
||||
'win32-x64': { file: 'bun-windows-x64-baseline.zip', sha256: '5'.repeat(64) },
|
||||
},
|
||||
licenseInventoryStatus: 'complete-for-bun-1.3.5-declared-linked-libraries',
|
||||
sourceManifest: 'build/bun-source-manifest.json',
|
||||
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizeTarget maps each supported electron-builder target without using the host', () => {
|
||||
assert.deepEqual(normalizeTarget('linux', 1), {
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
key: 'linux-x64',
|
||||
executableName: 'bun',
|
||||
});
|
||||
assert.equal(normalizeTarget('linux', 3).key, 'linux-arm64');
|
||||
assert.equal(normalizeTarget('darwin', 'x64').key, 'darwin-x64');
|
||||
assert.equal(normalizeTarget('darwin', 'arm64').key, 'darwin-arm64');
|
||||
assert.deepEqual(normalizeTarget('win32', 1), {
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
key: 'win32-x64',
|
||||
executableName: 'bun.exe',
|
||||
});
|
||||
assert.throws(() => normalizeTarget('freebsd', 'x64'), /Unsupported Bun runtime target platform/);
|
||||
assert.throws(() => normalizeTarget('win32', 'arm64'), /Unsupported Bun runtime target/);
|
||||
assert.throws(() => normalizeTarget('linux', 0), /Unsupported Bun runtime target architecture/);
|
||||
});
|
||||
|
||||
test('resolveArtifact chooses baseline x64 builds and standard arm64 builds', () => {
|
||||
const config = runtimeConfig();
|
||||
assert.equal(resolveArtifact(config, 'linux', 'x64').file, 'bun-linux-x64-baseline.zip');
|
||||
assert.equal(resolveArtifact(config, 'darwin', 'x64').file, 'bun-darwin-x64-baseline.zip');
|
||||
assert.equal(resolveArtifact(config, 'win32', 'x64').file, 'bun-windows-x64-baseline.zip');
|
||||
assert.equal(resolveArtifact(config, 'linux', 'arm64').file, 'bun-linux-aarch64.zip');
|
||||
assert.equal(resolveArtifact(config, 'darwin', 'arm64').file, 'bun-darwin-aarch64.zip');
|
||||
});
|
||||
|
||||
test('tracked runtime manifest covers every supported target at the packageManager version', async () => {
|
||||
const config = await loadRuntimeConfig();
|
||||
assert.equal(config.version, '1.3.5');
|
||||
for (const [platform, arch] of [
|
||||
['linux', 'x64'],
|
||||
['linux', 'arm64'],
|
||||
['darwin', 'x64'],
|
||||
['darwin', 'arm64'],
|
||||
['win32', 'x64'],
|
||||
]) {
|
||||
const artifact = resolveArtifact(config, platform, arch);
|
||||
assert.match(artifact.sha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(
|
||||
artifact.url,
|
||||
`https://github.com/oven-sh/bun/releases/download/bun-v1.3.5/${artifact.file}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('manifest version must match the exact packageManager Bun pin', () => {
|
||||
assert.equal(parsePackageManagerVersion({ packageManager: 'bun@1.3.5' }), '1.3.5');
|
||||
assert.throws(
|
||||
() => parsePackageManagerVersion({ packageManager: 'bun@^1.3.5' }),
|
||||
/must pin Bun exactly/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseRuntimeManifest(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
version: '1.3.6',
|
||||
bunRevision: '1e86cebd74a5723e818b5c0555276b646bcf0e4c',
|
||||
releaseTagCommit: 'fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab',
|
||||
artifacts: {},
|
||||
licenseInventoryStatus: 'partial',
|
||||
sourceManifest: 'build/bun-source-manifest.json',
|
||||
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
||||
},
|
||||
'1.3.5',
|
||||
),
|
||||
/does not match packageManager bun@1\.3\.5/,
|
||||
);
|
||||
});
|
||||
|
||||
test('ensureCachedArchive verifies downloads and reuses a verified cache entry offline', async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'subminer-bun-cache-'));
|
||||
const bytes = new TextEncoder().encode('verified Bun archive fixture');
|
||||
const artifact = {
|
||||
version: '1.3.5',
|
||||
file: 'bun-linux-x64-baseline.zip',
|
||||
sha256: sha256(bytes),
|
||||
url: 'https://example.invalid/bun.zip',
|
||||
};
|
||||
let downloads = 0;
|
||||
const fetchImpl = async () => {
|
||||
downloads += 1;
|
||||
return new Response(bytes);
|
||||
};
|
||||
|
||||
try {
|
||||
const firstPath = await ensureCachedArchive(artifact, { cacheDir: workspace, fetchImpl });
|
||||
const secondPath = await ensureCachedArchive(artifact, {
|
||||
cacheDir: workspace,
|
||||
fetchImpl: async () => {
|
||||
throw new Error('verified cache should not fetch');
|
||||
},
|
||||
});
|
||||
assert.equal(firstPath, secondPath);
|
||||
assert.equal(downloads, 1);
|
||||
assert.deepEqual(await fs.readFile(firstPath), Buffer.from(bytes));
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureCachedArchive rejects a checksum mismatch without caching the download', async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'subminer-bun-cache-mismatch-'));
|
||||
const artifact = {
|
||||
version: '1.3.5',
|
||||
file: 'bun-linux-x64-baseline.zip',
|
||||
sha256: '0'.repeat(64),
|
||||
url: 'https://example.invalid/bun.zip',
|
||||
};
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
ensureCachedArchive(artifact, {
|
||||
cacheDir: workspace,
|
||||
fetchImpl: async () => new Response('tampered'),
|
||||
}),
|
||||
/checksum mismatch/,
|
||||
);
|
||||
const cacheEntries = await fs.readdir(path.join(workspace, '1.3.5'));
|
||||
assert.deepEqual(cacheEntries, []);
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('extractZipMember extracts only the requested path and makes the runtime executable', async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'subminer-bun-extract-'));
|
||||
const archivePath = path.join(workspace, 'fixture.zip');
|
||||
const outputPath = path.join(workspace, 'output', 'bun');
|
||||
const archiveBase64 =
|
||||
'UEsDBAoAAAAAAMxcKl3rs337EgAAABIAAAAaABwAYnVuLWxpbnV4LXg2NC1iYXNlbGluZS9idW5VVAkAAyD5omog+aJqdXgLAAEE6AMAAAToAwAAYnVuIGZpeHR1cmUgYmluYXJ5UEsBAh4DCgAAAAAAzFwqXeuzffsSAAAAEgAAABoAGAAAAAAAAQAAAKSBAAAAAGJ1bi1saW51eC14NjQtYmFzZWxpbmUvYnVuVVQFAAMg+aJqdXgLAAEE6AMAAAToAwAAUEsFBgAAAAABAAEAYAAAAGYAAAAAAA==';
|
||||
|
||||
try {
|
||||
await fs.writeFile(archivePath, Buffer.from(archiveBase64, 'base64'));
|
||||
await extractZipMember(archivePath, 'bun-linux-x64-baseline/bun', outputPath);
|
||||
assert.equal(await fs.readFile(outputPath, 'utf8'), 'bun fixture binary');
|
||||
assert.equal((await fs.stat(outputPath)).mode & 0o777, 0o755);
|
||||
await assert.rejects(
|
||||
extractZipMember(archivePath, '../bun', path.join(workspace, 'unsafe')),
|
||||
/unsafe Bun archive member/,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows extraction keeps paths and archive members out of PowerShell command text', () => {
|
||||
const archivePath = String.raw`C:\Release Builds\bun $(archive) '1.3.5'.zip`;
|
||||
const member = 'bun-windows-x64-baseline/bun.exe';
|
||||
const outputPath = String.raw`C:\Staged App & Tools\resources\bun\bun.exe`;
|
||||
const command = buildWindowsExtractionCommand(archivePath, member, outputPath);
|
||||
const encodedCommand = command.args.at(-1);
|
||||
|
||||
assert.equal(command.command, 'powershell.exe');
|
||||
assert.equal(command.args.at(-2), '-EncodedCommand');
|
||||
assert.ok(encodedCommand);
|
||||
const script = Buffer.from(encodedCommand, 'base64').toString('utf16le');
|
||||
assert.match(script, /\$env:SUBMINER_BUN_ARCHIVE_PATH/);
|
||||
assert.match(script, /\$env:SUBMINER_BUN_ARCHIVE_MEMBER/);
|
||||
assert.match(script, /\$env:SUBMINER_BUN_OUTPUT_PATH/);
|
||||
assert.doesNotMatch(script, /Release Builds|Staged App|bun-windows-x64-baseline/);
|
||||
assert.deepEqual(command.environment, {
|
||||
SUBMINER_BUN_ARCHIVE_PATH: archivePath,
|
||||
SUBMINER_BUN_ARCHIVE_MEMBER: member,
|
||||
SUBMINER_BUN_OUTPUT_PATH: outputPath,
|
||||
});
|
||||
});
|
||||
|
||||
test('stageBunLicenses copies the tracked inventory into resources/bun/licenses', async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'subminer-bun-licenses-'));
|
||||
const sourceDirectory = path.join(workspace, 'tracked-licenses');
|
||||
const runtimeDirectory = path.join(workspace, 'resources', 'bun');
|
||||
|
||||
try {
|
||||
await fs.mkdir(sourceDirectory, { recursive: true });
|
||||
for (const fileName of [
|
||||
'Bun-LICENSE.md',
|
||||
'LGPL-2.0.txt',
|
||||
'LGPL-2.1.txt',
|
||||
'SOURCE.md',
|
||||
'THIRD-PARTY-NOTICES.md',
|
||||
]) {
|
||||
await fs.writeFile(path.join(sourceDirectory, fileName), `${fileName}\n`);
|
||||
}
|
||||
const licensesDirectory = await stageBunLicenses(runtimeDirectory, sourceDirectory);
|
||||
assert.equal(licensesDirectory, path.join(runtimeDirectory, 'licenses'));
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(licensesDirectory, 'THIRD-PARTY-NOTICES.md'), 'utf8'),
|
||||
'THIRD-PARTY-NOTICES.md\n',
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('stageBunRuntime places target executables and metadata in app resources with safe modes', async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'subminer-bun-stage-'));
|
||||
const cases = [
|
||||
{
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
appOutDir: path.join(workspace, 'linux'),
|
||||
relativeExecutable: path.join('resources', 'bun', 'bun'),
|
||||
member: 'bun-linux-x64-baseline/bun',
|
||||
},
|
||||
{
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
appOutDir: path.join(workspace, 'darwin'),
|
||||
relativeExecutable: path.join('SubMiner.app', 'Contents', 'Resources', 'bun', 'bun'),
|
||||
member: 'bun-darwin-aarch64/bun',
|
||||
},
|
||||
{
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
appOutDir: path.join(workspace, 'windows'),
|
||||
relativeExecutable: path.join('resources', 'bun', 'bun.exe'),
|
||||
member: 'bun-windows-x64-baseline/bun.exe',
|
||||
},
|
||||
] as const;
|
||||
|
||||
try {
|
||||
for (const targetCase of cases) {
|
||||
let extractedMember = '';
|
||||
const result = await stageBunRuntime(targetCase, {
|
||||
configLoader: async () => runtimeConfig(),
|
||||
archiveLoader: async () => path.join(workspace, 'fixture.zip'),
|
||||
extractor: async (_archivePath: string, member: string, outputPath: string) => {
|
||||
extractedMember = member;
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, 'bun fixture', { mode: 0o755 });
|
||||
},
|
||||
licenseStager: async (runtimeDirectory: string) => {
|
||||
const licensesDirectory = path.join(runtimeDirectory, 'licenses');
|
||||
await fs.mkdir(licensesDirectory, { recursive: true });
|
||||
await fs.writeFile(path.join(licensesDirectory, 'Bun-LICENSE.md'), 'MIT fixture');
|
||||
return licensesDirectory;
|
||||
},
|
||||
});
|
||||
const expectedExecutable = path.join(targetCase.appOutDir, targetCase.relativeExecutable);
|
||||
assert.equal(result.executablePath, expectedExecutable);
|
||||
assert.equal(extractedMember, targetCase.member);
|
||||
assert.equal((await fs.stat(expectedExecutable)).mode & 0o777, 0o755);
|
||||
assert.equal((await fs.stat(result.metadataPath)).mode & 0o777, 0o644);
|
||||
const metadata = JSON.parse(await fs.readFile(result.metadataPath, 'utf8'));
|
||||
assert.equal(metadata.version, '1.3.5');
|
||||
assert.equal(metadata.bunRevision, '1e86cebd74a5723e818b5c0555276b646bcf0e4c');
|
||||
assert.equal(metadata.artifactSha256, result.artifact.sha256);
|
||||
assert.equal(metadata.target, result.artifact.key);
|
||||
assert.equal(
|
||||
await fs.readFile(
|
||||
path.join(path.dirname(expectedExecutable), 'licenses', 'Bun-LICENSE.md'),
|
||||
'utf8',
|
||||
),
|
||||
'MIT fixture',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user